Skip to content

fix(falkordb): make the graph-DB push converge, batch its writes, add a repo-keyed delta (#3057) - #3069

Open
galshubeli wants to merge 1 commit into
Graphify-Labs:v8from
galshubeli:fix/falkordb-push-converge-v8
Open

fix(falkordb): make the graph-DB push converge, batch its writes, add a repo-keyed delta (#3057)#3069
galshubeli wants to merge 1 commit into
Graphify-Labs:v8from
galshubeli:fix/falkordb-push-converge-v8

Conversation

@galshubeli

@galshubeli galshubeli commented Aug 25, 2026

Copy link
Copy Markdown

Fixes #3057. Targets v8 directly — this is the upstream-mergeable version of the fix; see the note at the bottom.

The defect

push_to_falkordb is MERGE-only. Once graphify global add prunes a repo out of the global graph, a full re-push leaves every one of those nodes in the target permanentlygraph.json and the database diverge and never converge back, no matter how many times you re-push. @Azeem1985 measured it: +1,250 nodes / +1,151 edges of surplus on a 75k-node global graph, and 25 of 25 sampled pruned ids still present after a full re-push.

While fixing it, a second problem surfaced that is not in the report. Measured here, 20 repos / 20k nodes / 20k edges:

OLD per-row full push    79.73s
NEW batched full push     2.90s   (27x)

The old path sent one query per node and one per edge, and matched edge endpoints with a label-free MATCH (a {id: $src}) that no index can serve — because nodes carried only their file-type label (:Python), there was no shared label to index. That is @kingjin94's #2258 diagnosis, and it is fixed here by giving every node :Entity alongside its file-type label and indexing n.id.

What changed

Batched writes on an indexed shared label. UNWIND in 1000-row batches, MERGE (n:Entity {id: row.id}) SET n:Python SET n += row, with CREATE INDEX FOR (n:Entity) ON (n.id). The file-type label is kept, not replaced, so existing queries keyed on it still work. A one-time backfill (MATCH (n) WHERE n.id IS NOT NULL AND NOT n:Entity SET n:Entity) adopts nodes written by an older graphify — without it, the MERGE switch would create a duplicate beside every existing node. There is a test asserting both labels survive.

--graph-name. The writer has always taken graph_name; the CLI never passed it, so every push through the CLI landed on the graphify key with no flag to send it elsewhere. You could not rehearse a push, run a staging graph beside production, or point two environments at one instance without dropping to Python. This is also what turned @Azeem1985's mistake into a 1.2M-node deletion.

--prune converges the target — nodes and edges. DETACH DELETE on a stale node takes its edges with it, but an edge dropped between two endpoints that both survive needs its own sweep; without it the surplus-edge half of the report lives on. Opt-in rather than default: --graph-name never existed, so some graphify keys genuinely hold several projects merged together, and a converging default would either silently delete the others (under the guard) or hard-refuse a routine re-push (over it). Neither is acceptable in a patch upgrade — @Azeem1985's framing, and it is better than my original reasoning.

An add-only push reports the drift it cannot fix. The core complaint in #3057 is that the divergence is silent, and that stays true on the default path if the only fix is a flag nobody passes. So a non-pruning push now counts the nodes it did not stamp — the same query the prune path deletes by, so there is one definition of drift rather than two that can disagree — and the CLI prints one line:

  note: target has 2 node(s) the source does not; --prune converges it.

Repo-keyed delta, via the new graphify global push (delta by default). This mirrors global_add's own contract instead of inventing one: the repo is already its unit of change — it prunes a repo whole, re-adds it whole, records a per-repo source_hash, and already returns skipped=True when that hash has not moved. The push now consumes the same signal.

wall rows sent
full push, nothing changed 2.90s 39,999
delta, nothing changed 0.01s 0
delta, 1 of 20 repos changed 0.39s 2,001 (5.0%)

Delta output is identical to a from-scratch load on node and edge counts.

Drift repair. Push state lives in the target as :GraphifyPushState nodes and is cross-checked against the target's own per-repo counts (one aggregate). A local ledger still reads clean after the database is wiped or a run half-lands, and the delta would skip forever — @Azeem1985's finding #2. There is a test that deletes part of a repo behind the push's back, leaves the hashes untouched, and asserts the repo is rebuilt.

Cross-repo edges survive a delta. global_add remaps external-library nodes onto whichever repo first contributed them, so a B -> A edge is owned by neither repo alone. Pruning A drops it, and re-adding only A's internal edges would erode cross-repo connectivity a little on every delta — silently. The delta buckets edges by either endpoint's repo. Pinned by a test.

Delete guards. Capped at 20% of the target unless --allow-shrink — the same "refuse to SILENTLY drop nodes" rule as the #479 build guard. Only net removal counts: a re-pushed repo is pruned and immediately re-added, so charging it would refuse any delta touching more than 20% of a small global graph; a repo that comes back smaller is charged the difference, which is what catches "the manifest says 2 nodes, the target holds 50,000". Deletes are paged with the LIMIT inside a WITH, because FalkorDB's LIMIT does not short-circuit an eager DELETE... DETACH DELETE n LIMIT $page deletes the whole label. That is @Azeem1985's finding #1, and the reason sits in a comment at the call site.

graphify global push. export --push resolves its source from a project's output directory, so the global graph — the one graph this entire contract is about — had no CLI push route at all.

Convergence, measured

OLD full re-push, after the source pruned   -> 20,240 nodes   converged=False
NEW prune re-push                           -> 20,000 nodes   converged=True

Tests

15 new integration tests against a live FalkorDB: both labels, add-only default, drift reporting, node convergence, edge-only convergence, prune idempotence, the guard refusing and not deleting, target isolation by --graph-name, delta skip / selective re-send / repo removal, cross-repo edge preservation, drift repair, and a foreign manifest being refused.

Verified they catch the bugs rather than just passing: all 15 fail on this commit's parent, all pass here (15 failed / 2 passed against the parent; the 2 are the pre-existing create/idempotence tests). Independently reproduced by @Azeem1985 on the rig that produced the original #3057 measurements. Full suite is +15 passing with no new failures (this environment has 557 pre-existing failures from missing tree-sitter grammars, identical before and after).

Deliberately not included

The Neo4j writer is untouched — byte-identical to the parent, verified by AST comparison. It has the same defects (per-row, unindexed, never deletes, no database parameter), but a fix cannot be verified without a Neo4j instance. The new flags are refused on export neo4j rather than silently ignored: accepting --prune there and doing nothing would report a converged push that converged nothing — the same class of lie the missing-label bug told. Follow-up when there is an instance to test against.

No CHANGELOG entry — this repo folds those at release (e.g. 1c6b3db), per @Azeem1985.

Streaming graph.json into the push instead of loading it through NetworkX is not in here. It is a real problem (@Azeem1985 reports a 1.87GB graph peaking ~5.3GB RSS and getting OOM-killed before a row went out), but it is a separate change to how export loads graphs and belongs in its own PR, the way it was originally offered.

Note on provenance

This work was first written against a FalkorDB-backend branch where graphify/store.py provides a GraphStore with a batched writer, and reviewed there by @Azeem1985 on the box that produced the original #3057 numbers (review). That version is not portable to v8, which has no store.py at all — every symbol it leaned on is absent, so a cherry-pick auto-merges four of five files and then ImportErrors on first use. This PR is the v8-native implementation of the same contract: the batched writer is hand-rolled in graphdb.py, and the delta buckets the in-memory graph by repo in two passes rather than querying the source. Same tests, same guards, same measured behaviour.

Relationship to #2312

#2312 proposes a Neo4j source-of-truth backend. This is not competing with it — it is the push-side piece that design needs regardless, and it is small enough to land independently.

… a repo-keyed delta (Graphify-Labs#3057)

`push_to_falkordb` is MERGE-only, so once `graphify global add` prunes a repo
out of the global graph, a full re-push leaves every one of those nodes in the
target permanently: the database diverges from graph.json and never converges
back. @Azeem1985 measured +1,250 nodes / +1,151 edges of surplus on a 75k-node
global graph, with 25 of 25 sampled pruned ids still present after a full
re-push.

The same code path also sent one query per node and one per edge, and matched
edge endpoints with a label-free `MATCH (a {id: $src})` that no index can
serve. On 20 repos / 20k nodes a full push took 79.7s; batched UNWIND against
an indexed `:Entity` label takes 2.9s — 27x — and the labelling change is what
makes the index reachable at all, since nodes previously carried only their
file-type label.

What is new:

- Batched UNWIND writes, an `:Entity` label indexed on `id` alongside the
  existing file-type label, and a one-time backfill so MERGE adopts nodes
  written by an older graphify instead of duplicating them. This is also
  @kingjin94's Graphify-Labs#2258 diagnosis.
- `--graph-name`. The parameter always existed on the writer; the CLI never
  passed it, so every CLI push landed on the `graphify` key with no way to aim
  it elsewhere. That is what turned a mistake into a 1.2M-node deletion.
- `--prune` converges the target: nodes AND edges. DETACH DELETE takes a
  pruned node's edges with it, but an edge dropped between two surviving
  endpoints needs its own sweep, which is the surplus-edge half of the report.
  Opt-in, because `--graph-name` never existed and some `graphify` keys hold
  several projects merged together, where a converging default would either
  silently delete or hard-refuse on a routine re-push.
- An add-only push now reports the drift it cannot fix, so the divergence stops
  being silent even when nobody passes `--prune`.
- Repo-keyed delta (`graphify global push`, default). Mirrors global_add's own
  contract: the repo is the unit of change, keyed on the manifest source_hash
  global_add already records and already uses for its own skip. 5.0% of the rows
  for a 1-of-20-repo change; 0 rows when nothing moved.
- Drift repair. Push state lives in the target as :GraphifyPushState nodes,
  cross-checked against the target's own per-repo counts. A local ledger still
  reads clean after a wipe or a half-landed run and never repairs it.
- Cross-repo edges survive a delta. global_add remaps external-library nodes
  onto whichever repo first contributed them, so a B->A edge is owned by
  neither alone; the delta re-sends every edge incident to a rewritten repo,
  not only its internal ones.
- Deletes are capped at 20% of the target unless `--allow-shrink` (the Graphify-Labs#479
  rule applied to the push) and paged with the LIMIT inside a WITH, because
  FalkorDB's LIMIT does not short-circuit an eager DELETE.
- `graphify global push`. `export --push` resolves its source from a project
  output directory, so the global graph had no CLI push route at all.

The Neo4j writer is untouched — same defects, but a fix cannot be verified
without a Neo4j instance. The new flags are refused on `export neo4j` rather
than silently ignored, since accepting `--prune` there would report a
converged push that converged nothing.

15 new integration tests against a live FalkorDB; they fail on this commit's
parent and pass here. No CHANGELOG entry — this repo folds those at release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds FalkorDB push support with a --graph-name target selector (default graphify), --prune to mirror deletions, and --allow-shrink to override the prune size guard, wiring these through graphify export falkordb and reporting pruned counts plus a "target has surplus nodes" note when pushing without prune. Adds graphify global push <URI>, which is delta by default (re-sending only repos whose source or target count drifted) and takes --full/--prune to re-send everything and converge the target. Rejects --graph-name/--prune/--allow-shrink on the Neo4j path rather than silently ignoring them, and surfaces the prune size guard as a ValueError that exits non-zero.

Worth a look

  • Delta mode ignores repos present in manifest but absent from targetgraphify/exporters/graphdb.py · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • push_to_falkordb node labels changed from per-file-type to fixed :Entity, breaking existing graph queriesgraphify/exporters/graphdb.py · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Global push accepts database password on the command linegraphify/cli.py:3028 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • removed repos delete nodes but not their :State stamp counted correctly / edges lost across reposgraphify/exporters/graphdb.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Push epoch can collide for concurrent pushes in the same millisecondgraphify/exporters/graphdb.py:49 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 367 functions depend on the 170 functions this change touches.

Health — this change adds coupling hotspots:

  • new: dispatch_command() — 2 callers, 123 callees
  • new: push_to_falkordb() — 20 callers, 8 callees
  • new: global_add() — 10 callers, 8 callees
  • new: _stale_graph_sources() — 7 callers, 6 callees
  • new: _run_hook_guard() — 4 callers, 7 callees
  • new: global_remove() — 5 callers, 5 callees
  • new: _push_delta() — 1 callers, 8 callees
  • new: test_poisoned_manifest_is_healed() — 0 callers, 6 callees

Verification — 367 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 320 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify push\_to\_falkordb.

The verifier did not have enough to check push\_to\_falkordb, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly ImportError — names the real obstacle, not a sampling gap)

· 2 grounded finding(s) anchored inline below; 6 more finding(s) on lines outside this diff (see the check run).

return n


def _push_delta(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_push_delta()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

}


def push_to_falkordb(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionpush_to_falkordb()

fans out to 8 callees (efferent coupling); 20 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@Azeem1985

Copy link
Copy Markdown
Contributor

Independent verification from the box that produced the #3057 numbers — same rig that reviewed the falkordb-backend version of this change (here).

Setup: clean clone of 24c45a0 (parent 282976b, current v8 tip), live falkordb/falkordb:latest, scratch instance, scratch keys, Python 3.14.

Test suite: 17/17 pass in ~1.3s (15 new + 2 pre-existing). Negative control — the same test file against the parent commit: 15 failed / 2 passed, i.e. every one of the 15 new tests fails on the parent. That is one more than the PR's own "14 of 15"; same counting difference as on the fork review — a test that dies on a then-unknown kwarg is the test catching the missing API, so I count it as failing honestly.

CLI placement, since the fork thread reported a near-miss where the global push block first landed in the provider chain: what shipped is correct. graphify provider push <URI> refuses with Usage: graphify provider [add|list|show|remove], and global push routes to the global chain.

graphify global push end-to-end, in a sandboxed $HOME against the scratch instance: added two toy repos, initial push delta-pushed both; an immediate re-push printed up to date, 2 repo(s) unchanged and sent 0 rows; after changing one repo, only that repo was re-sent (3 nodes, 2 edges across 1 repo(s); 1 unchanged, 3 nodes pruned). Read-back: exactly the source's 5 :Entity ids — the stale id gone, the renamed id present — plus the 2 per-repo :GraphifyPushState nodes, as designed.

Neo4j refusal: export neo4j with --pruneerror: --prune is supported only by 'graphify export falkordb'., and the same for --graph-name. Refused, not ignored, as the description says.

I did not re-run the 20-repo/20k-node perf corpus, so I can't independently confirm the 27x — but the shape of the claim is right: once a branch's own writes are batched, the exporter is the last per-row path standing, and v8 had never batched it.

This matches the contract as reviewed on the fork thread — --prune opt-in with the drift one-liner on the default path, the shrink guard refusing before deleting, push state in the target rather than a local ledger. From the reporter's side, this closes #3057.

@Azeem1985

Copy link
Copy Markdown
Contributor

Following up the verification run above — I also executed the two Escalate · high findings, since the review marks them "NOT verified (no proof, no reproducing execution)". Neither holds on head 24c45a0:

"Delta mode ignores repos present in manifest but absent from target"_plan_delta (graphify/exporters/graphdb.py:338-345) re-sends on either of the two ways a manifest repo can be absent from the target: no :State entry (known is None → reason "not present in target"), and node-count drift (live != want_count → reason "target drift"), which also covers the sneakier case where the state row survived but the nodes are gone (live == 0). The suite ships a test for exactly that: test_delta_repairs_drift_a_ledger_would_miss.

"push_to_falkordb node labels changed from per-file-type to fixed :Entity, breaking existing graph queries" — the writer keeps both labels: MERGE (n:Entity {id: row.id}) SET n:{ftype} (graphdb.py:196-197), so nodes come out as :Entity:Python etc., and _ensure_schema (graphdb.py:166-177) backfills :Entity onto nodes pushed by older versions so MERGE can't duplicate them. Old per-file-type queries and new :Entity queries both keep working. Shipped tests: test_pushed_nodes_carry_the_entity_label, test_pushed_nodes_keep_their_file_type_label_too.

Re-ran the four relevant tests just now against a scratch FalkorDB on this head (the two label tests, test_delta_repairs_drift_a_ledger_would_miss, test_delta_resends_only_the_changed_repo): 4 passed.

The three medium items read as author's-choice questions (CLI password ergonomics, the same-millisecond epoch window, :State accounting for removed repos) — leaving those to you, but happy to run any of them through the same rig if useful.

@galshubeli

Copy link
Copy Markdown
Author

Thanks — and you are right about the count, so I have corrected the PR description rather than leave a number in it that I cannot reproduce.

15 failed / 2 passed against the parent, confirmed here too. The cause of my "14 of 15" is boring and entirely mine: I ran the negative control before adding the drift-notice test you asked for on the fork thread, and never re-ran it after. So the 15th test existed by the time I wrote the description but had never been measured against the parent. It fails there like the rest — KeyError: 'deleted' / no target_surplus key — which is the missing API being caught, exactly as you count it. Description now says all 15, and credits your reproduction.

That is the second time your counting has been right and mine stale on the same axis, which suggests my habit of measuring the negative control once and then continuing to edit the test file is the actual defect. Re-running it as the last step before writing the description is the fix.

On the perf corpus you did not re-run: entirely reasonable not to, and the claim does not need to carry the review. For the record, the two branches measured 4.1s -> 0.4s and 79.7s -> 2.9s for the same 20-repo / 20k-node push, and your reading of why is exactly right — the other branch had already batched its own writes, so its exporter was a smaller share of the total. Anyone who wants to check it needs only a scratch key and the loop in the description; nothing in the correctness argument rests on it.

Everything else you exercised — provider/global routing, the delta skip and selective re-send, the read-back showing the stale id gone and the :GraphifyPushState pair present, the Neo4j refusal — matches what I see. Nothing outstanding from my side.

@Azeem1985

Copy link
Copy Markdown
Contributor

Description edit confirmed — it now reads all 15 failing against the parent, with the reproduction credited. That settles the only number that was open.

Agreed on the habit being the real defect: a negative control measured once while the test file keeps moving is the same class of error as quoting a benchmark from an earlier commit, and re-running it as the last step before writing the description is the mechanism that makes it impossible rather than unlikely.

Thanks for putting the perf pair on the record — 79.7s → 2.9s here vs 4.1s → 0.4s on the branch that had already batched its own writes is exactly the split the "exporter was the last per-row path standing" reading predicts, so the 27x now carries both a reproducible loop and a coherent mechanism, even if nobody re-runs it.

Nothing outstanding from my side either — from the reporter's seat, #3057 closes when this merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Graph-DB push never deletes: a pruned repo survives in Neo4j/FalkorDB forever (measured), and a delta push fixes it

2 participants